C++ 线程安全单例:DCLP、Meyers Singleton 与 inline static
单例模式常用于管理编译器、执行器、配置中心这类全局唯一的重量级资源。多线程环境下,单例的核心风险集中在初始化阶段:两个线程同时首次调用 getInstance() 时,如何保证实例只被构造一次、且其他线程不会拿到"构造了一半"的对象。本文按时间线整理 C++ 线程安全单例的四种实现,说明各自的问题与适用场景,给出选型结论。
多线程下的两个核心问题
- 竞态条件:两个线程同时通过
if (instance == nullptr)检查,构造函数被调用两次。当初始化涉及文件句柄、硬件设备等资源时是灾难性的。 - 未同步的读写:一个线程在写(初始化)实例成员,另一个线程在读。没有正确的内存屏障时,读线程可能看到部分构造的对象——内存已分配但内部指针尚未初始化,属于未定义行为。
方案一:双检锁(DCLP)——传统方案,不推荐
Singleton* Singleton::getInstance() {
if (pInstance == nullptr) { // 第一次检查:避免每次调用都加锁
std::lock_guard<std::mutex> lock(mutex);
if (pInstance == nullptr) { // 第二次检查:锁内只创建一次
pInstance = new Singleton(); // 问题所在
}
}
return pInstance;
} 问题出在 pInstance = new Singleton(); 不是原子操作,它分三步:
- 分配内存;
- 在内存上构造对象;
- 将地址赋值给
pInstance。
编译器优化和 CPU 指令重排可能使步骤 2、3 互换。如果线程 A 执行完步骤 1、3 后(pInstance 已非空)被挂起,线程 B 调用 getInstance() 时看到非空指针,直接返回一个未构造完成的对象。
修复需要手动插入内存屏障:把 pInstance 声明为 std::atomic<Singleton*>,写侧用 memory_order_release、读侧用 memory_order_acquire。写法正确但代码复杂、易错、难以审查,现代 C++ 中没有理由再手写。
方案二:C++11 静态局部变量(Meyers Singleton)——首选
Singleton& Singleton::getInstance() {
static Singleton instance; // 线程安全的初始化("magic statics")
return instance;
} C++11 标准规定:如果控制流并发地进入某个局部静态变量的初始化,其他线程应当阻塞等待初始化完成([stmt.dcl]/4)。编译器据此在底层插入互斥锁或等价的原子同步,保证对象只构造一次。
这个写法的优点:
- 极简:代码量最少,意图清晰;
- 懒加载:第一次调用才创建;多个单例有依赖关系时可以自然处理,避免静态初始化顺序问题;
- 异常安全:构造抛出异常视为未完成初始化,下次调用会重新尝试(guard 变量只在成功完成时置位)。
一个边界情况要注意:递归重入初始化是未定义行为——如果构造函数内部又(直接或间接)调用了 getInstance(),控制流在初始化未完成时重入声明,实践中通常表现为死锁或崩溃。单例构造函数里不要回调自身。
配合删除拷贝构造/赋值、私有化构造函数,就是完整的实现:
class Compiler {
public:
Compiler(const Compiler&) = delete;
Compiler& operator=(const Compiler&) = delete;
static Compiler& getInstance() {
static Compiler instance;
return instance;
}
private:
Compiler() = default;
}; 方案三:std::call_once + std::once_flag
Singleton& Singleton::getInstance() {
std::call_once(initFlag, []() { pInstance.reset(new Singleton); });
return *pInstance;
} 同样是标准保证线程安全,明确表达"只执行一次"的意图;相比 Meyers 写法需要定义静态成员并在类外初始化,代码更冗长。适合初始化逻辑复杂、不适合塞进一个构造表达式的场景。
方案四:C++17 inline 静态成员变量
class Singleton {
public:
static Singleton& getInstance() { return instance; }
private:
Singleton() = default;
inline static Singleton instance{}; // 类内直接定义并初始化
}; - 线程安全:在进入
main之前完成初始化,由运行时保证; - 非懒加载:程序启动即创建,可能增加启动开销;
- inline 变量(C++17)解决了跨编译单元重复定义问题,不再需要类外定义。
适合启动时必须就绪、不能容忍首次调用延迟的资源。
选型总结
| 时代 | 方案 | 特点 | 推荐度 |
|---|---|---|---|
| C++11 之前 | 双检锁 | 复杂、易错、需手动处理内存序 | 不推荐 |
| C++11 起 | 静态局部变量 | 简洁、安全、懒加载 | 首选 |
| C++11 起 | std::call_once | 明确、安全、稍冗长 | 备选 |
| C++17 起 | inline 静态成员 | 简单、安全、非懒加载 | 启动期必须初始化的资源 |
工程原则:单例初始化通常只发生一次,"更快的单例"没有意义,正确性和可读性优先。只有实测证明初始化是系统瓶颈时才考虑其他方案,而这种情况极罕见。
另一个常被忽略的维度是析构:跨编译单元的静态析构顺序同样是未指定的,Meyers Singleton 只保证初始化安全、不保证销毁时序,退出期 use-after-free 的成因与四种应对方式,是一个独立的话题。